-
Notifications
You must be signed in to change notification settings - Fork 0
Check and remove grants and role memberships before removing a role #39
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
Conversation
WalkthroughAdds a multi-step safe deletion workflow for PostgreSQL roles: introduces ownership checks, grant revocation, membership removal, and helper methods; updates DeleteRole to delegate to SafeDeleteRole which orchestrates validation, checks, revocations, removals, DROP ROLE, and logging. Changes
Sequence Diagram(s)sequenceDiagram
autonumber
actor Caller
participant Client
participant Postgres as PostgreSQL
Caller->>Client: DeleteRole(roleName)
Client->>Client: delegate to SafeDeleteRole(roleName)
Client->>Client: validate non-empty roleName
Client->>Postgres: RoleOwnsObjects(roleName)?
Postgres-->>Client: ownsObjects (bool) / error
alt ownsObjects == true
Client-->>Caller: error (role owns objects)
else
Client->>Postgres: RevokeAllGrantsFromRole(roleName)
Postgres-->>Client: ok / error
Client->>Postgres: RemoveRoleFromAllRoles(roleName)
Postgres-->>Client: ok / error
Client->>Postgres: DROP ROLE roleName
Postgres-->>Client: ok / error
Client-->>Caller: success
end
note over Client,Postgres: Each step logs context and surfaces errors
Estimated code review effort🎯 3 (Moderate) | ⏱️ ~25 minutes Poem
Pre-merge checks and finishing touches✅ Passed checks (3 passed)
✨ Finishing touches
🧪 Generate unit tests (beta)
📜 Recent review detailsConfiguration used: CodeRabbit UI Review profile: CHILL Plan: Pro ⛔ Files ignored due to path filters (1)
📒 Files selected for processing (1)
🚧 Files skipped from review as they are similar to previous changes (1)
⏰ Context from checks skipped due to timeout of 90000ms. You can increase the timeout in your CodeRabbit configuration to a maximum of 15 minutes (900000ms). (1)
Comment |
6cb9ad6 to
5ec8362
Compare
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Actionable comments posted: 3
🧹 Nitpick comments (3)
pkg/postgres/roles.go (3)
164-188: Optimize repeated subquery for role OID lookup.The subquery
(SELECT oid FROM pg_roles WHERE rolname = $1)is executed seven times within the UNION ALL, which is inefficient. Consider using a CTE to fetch the OID once and reference it in all subsequent checks.Apply this diff to optimize the query:
query := ` - SELECT EXISTS( - SELECT 1 FROM ( - -- Check for owned schemas - SELECT 1 FROM pg_namespace WHERE nspowner = (SELECT oid FROM pg_roles WHERE rolname = $1) - UNION ALL - -- Check for owned tables - SELECT 1 FROM pg_class WHERE relowner = (SELECT oid FROM pg_roles WHERE rolname = $1) - UNION ALL - -- Check for owned functions - SELECT 1 FROM pg_proc WHERE proowner = (SELECT oid FROM pg_roles WHERE rolname = $1) - UNION ALL - -- Check for owned sequences - SELECT 1 FROM pg_class WHERE relowner = (SELECT oid FROM pg_roles WHERE rolname = $1) AND relkind = 'S' - UNION ALL - -- Check for owned views - SELECT 1 FROM pg_class WHERE relowner = (SELECT oid FROM pg_roles WHERE rolname = $1) AND relkind = 'v' - UNION ALL - -- Check for owned types - SELECT 1 FROM pg_type WHERE typowner = (SELECT oid FROM pg_roles WHERE rolname = $1) - UNION ALL - -- Check for owned databases - SELECT 1 FROM pg_database WHERE datdba = (SELECT oid FROM pg_roles WHERE rolname = $1) - ) owned_objects - )` + WITH role_oid AS ( + SELECT oid FROM pg_roles WHERE rolname = $1 + ) + SELECT EXISTS( + SELECT 1 FROM ( + -- Check for owned schemas + SELECT 1 FROM pg_namespace WHERE nspowner = (SELECT oid FROM role_oid) + UNION ALL + -- Check for owned tables, sequences, views, etc. + SELECT 1 FROM pg_class WHERE relowner = (SELECT oid FROM role_oid) + UNION ALL + -- Check for owned functions + SELECT 1 FROM pg_proc WHERE proowner = (SELECT oid FROM role_oid) + UNION ALL + -- Check for owned types + SELECT 1 FROM pg_type WHERE typowner = (SELECT oid FROM role_oid) + UNION ALL + -- Check for owned databases + SELECT 1 FROM pg_database WHERE datdba = (SELECT oid FROM role_oid) + ) owned_objects + )`
329-370: Consider wrapping operations in a transaction.The safe deletion process involves multiple database operations (checking ownership, revoking grants, removing memberships, dropping the role). If any step fails midway, the database is left in an inconsistent state. While the operations are idempotent and can be retried, wrapping them in a transaction would ensure atomicity.
Note: PostgreSQL DDL statements like
REVOKEandDROP ROLEare transactional, so a transaction would provide rollback capability if any step fails.Example structure:
func (c *Client) SafeDeleteRole(ctx context.Context, roleName string) error { l := ctxzap.Extract(ctx) if roleName == "" { return errors.New("role name cannot be empty") } // Start transaction tx, err := c.db.Begin(ctx) if err != nil { return err } defer tx.Rollback(ctx) // Perform operations using tx instead of c.db // ... ownership check, revoke grants, remove memberships, drop role ... // Commit transaction return tx.Commit(ctx) }However, implementing this requires refactoring
RevokeAllGrantsFromRoleandRemoveRoleFromAllRolesto accept a transaction or executor interface.
200-276: Consider revoking DEFAULT PRIVILEGES.The current implementation revokes direct grants on existing objects but does not revoke DEFAULT PRIVILEGES. If the role has DEFAULT PRIVILEGES grants (which apply to future objects), those should also be revoked before deletion.
Add this after line 273:
} + // Revoke default privileges + for _, schema := range schemas { + sanitizedSchema := pgx.Identifier{schema}.Sanitize() + revokeDefaultQuery := fmt.Sprintf("ALTER DEFAULT PRIVILEGES FOR ROLE %s IN SCHEMA %s REVOKE ALL ON TABLES FROM %s", sanitizedRoleName, sanitizedSchema, sanitizedRoleName) + l.Debug("revoking default table privileges", zap.String("query", revokeDefaultQuery)) + if _, err := c.db.Exec(ctx, revokeDefaultQuery); err != nil { + l.Error("error revoking default table privileges", zap.String("schema", schema), zap.Error(err)) + return err + } + // Repeat for SEQUENCES, FUNCTIONS, TYPES as needed + } + revokeDbQuery := fmt.Sprintf("REVOKE ALL ON DATABASE %s FROM %s", pgx.Identifier{c.DatabaseName()}.Sanitize(), sanitizedRoleName)Note: This requires checking which roles the target role granted DEFAULT PRIVILEGES for, which may require querying
pg_default_acl.
📜 Review details
Configuration used: CodeRabbit UI
Review profile: CHILL
Plan: Pro
📒 Files selected for processing (1)
pkg/postgres/roles.go(1 hunks)
🧰 Additional context used
🧬 Code graph analysis (1)
pkg/postgres/roles.go (1)
pkg/postgres/client.go (2)
Client(98-102)New(121-148)
🪛 GitHub Check: go-lint
pkg/postgres/roles.go
[failure] 278-278:
Comment should end in a period (godot)
🔇 Additional comments (3)
pkg/postgres/roles.go (3)
279-327: LGTM!The logic correctly identifies parent roles and removes the target role from each membership. Error handling appropriately returns errors immediately.
343-345: Good error message for ownership check.The error message clearly instructs users to transfer ownership or drop objects before deleting the role, which is helpful for troubleshooting.
372-374: LGTM!The delegation to
SafeDeleteRolemaintains backward compatibility while implementing the safer deletion logic.
| if _, err := c.db.Exec(ctx, revokeTablesQuery); err != nil { | ||
| l.Warn("error revoking table grants", zap.String("schema", schema), zap.Error(err)) | ||
| } | ||
|
|
||
| revokeSequencesQuery := fmt.Sprintf("REVOKE ALL ON ALL SEQUENCES IN SCHEMA %s FROM %s", sanitizedSchema, sanitizedRoleName) | ||
| l.Debug("revoking sequence grants", zap.String("query", revokeSequencesQuery)) | ||
| if _, err := c.db.Exec(ctx, revokeSequencesQuery); err != nil { | ||
| l.Warn("error revoking sequence grants", zap.String("schema", schema), zap.Error(err)) | ||
| } | ||
|
|
||
| revokeFunctionsQuery := fmt.Sprintf("REVOKE ALL ON ALL FUNCTIONS IN SCHEMA %s FROM %s", sanitizedSchema, sanitizedRoleName) | ||
| l.Debug("revoking function grants", zap.String("query", revokeFunctionsQuery)) | ||
| if _, err := c.db.Exec(ctx, revokeFunctionsQuery); err != nil { | ||
| l.Warn("error revoking function grants", zap.String("schema", schema), zap.Error(err)) | ||
| } | ||
|
|
||
| revokeTypesQuery := fmt.Sprintf("REVOKE ALL ON ALL TYPES IN SCHEMA %s FROM %s", sanitizedSchema, sanitizedRoleName) | ||
| l.Debug("revoking type grants", zap.String("query", revokeTypesQuery)) | ||
| if _, err := c.db.Exec(ctx, revokeTypesQuery); err != nil { | ||
| l.Warn("error revoking type grants", zap.String("schema", schema), zap.Error(err)) | ||
| } | ||
|
|
||
| revokeSchemaQuery := fmt.Sprintf("REVOKE ALL ON SCHEMA %s FROM %s", sanitizedSchema, sanitizedRoleName) | ||
| l.Debug("revoking schema grants", zap.String("query", revokeSchemaQuery)) | ||
| if _, err := c.db.Exec(ctx, revokeSchemaQuery); err != nil { | ||
| l.Warn("error revoking schema grants", zap.String("schema", schema), zap.Error(err)) | ||
| } | ||
| } | ||
|
|
||
| revokeDbQuery := fmt.Sprintf("REVOKE ALL ON DATABASE %s FROM %s", pgx.Identifier{c.DatabaseName()}.Sanitize(), sanitizedRoleName) | ||
| l.Debug("revoking database grants", zap.String("query", revokeDbQuery)) | ||
| if _, err := c.db.Exec(ctx, revokeDbQuery); err != nil { | ||
| l.Warn("error revoking database grants", zap.Error(err)) | ||
| } |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Errors during grant revocation should not be silenced.
Grant revocation errors are logged as warnings but not returned (lines 241, 247, 253, 259, 265, 272). If revocation fails, SafeDeleteRole proceeds to drop the role, which may then fail with unclear error messages. Additionally, partial revocation leaves the database in an inconsistent state.
Consider either:
- Returning the first revocation error immediately to halt the process.
- Collecting all errors and returning them as a combined error after attempting all revocations.
Apply this diff for approach 1 (fail fast):
revokeTablesQuery := fmt.Sprintf("REVOKE ALL ON ALL TABLES IN SCHEMA %s FROM %s", sanitizedSchema, sanitizedRoleName)
l.Debug("revoking table grants", zap.String("query", revokeTablesQuery))
if _, err := c.db.Exec(ctx, revokeTablesQuery); err != nil {
- l.Warn("error revoking table grants", zap.String("schema", schema), zap.Error(err))
+ l.Error("error revoking table grants", zap.String("schema", schema), zap.Error(err))
+ return err
}
revokeSequencesQuery := fmt.Sprintf("REVOKE ALL ON ALL SEQUENCES IN SCHEMA %s FROM %s", sanitizedSchema, sanitizedRoleName)
l.Debug("revoking sequence grants", zap.String("query", revokeSequencesQuery))
if _, err := c.db.Exec(ctx, revokeSequencesQuery); err != nil {
- l.Warn("error revoking sequence grants", zap.String("schema", schema), zap.Error(err))
+ l.Error("error revoking sequence grants", zap.String("schema", schema), zap.Error(err))
+ return err
}
revokeFunctionsQuery := fmt.Sprintf("REVOKE ALL ON ALL FUNCTIONS IN SCHEMA %s FROM %s", sanitizedSchema, sanitizedRoleName)
l.Debug("revoking function grants", zap.String("query", revokeFunctionsQuery))
if _, err := c.db.Exec(ctx, revokeFunctionsQuery); err != nil {
- l.Warn("error revoking function grants", zap.String("schema", schema), zap.Error(err))
+ l.Error("error revoking function grants", zap.String("schema", schema), zap.Error(err))
+ return err
}
revokeTypesQuery := fmt.Sprintf("REVOKE ALL ON ALL TYPES IN SCHEMA %s FROM %s", sanitizedSchema, sanitizedRoleName)
l.Debug("revoking type grants", zap.String("query", revokeTypesQuery))
if _, err := c.db.Exec(ctx, revokeTypesQuery); err != nil {
- l.Warn("error revoking type grants", zap.String("schema", schema), zap.Error(err))
+ l.Error("error revoking type grants", zap.String("schema", schema), zap.Error(err))
+ return err
}
revokeSchemaQuery := fmt.Sprintf("REVOKE ALL ON SCHEMA %s FROM %s", sanitizedSchema, sanitizedRoleName)
l.Debug("revoking schema grants", zap.String("query", revokeSchemaQuery))
if _, err := c.db.Exec(ctx, revokeSchemaQuery); err != nil {
- l.Warn("error revoking schema grants", zap.String("schema", schema), zap.Error(err))
+ l.Error("error revoking schema grants", zap.String("schema", schema), zap.Error(err))
+ return err
}
}
revokeDbQuery := fmt.Sprintf("REVOKE ALL ON DATABASE %s FROM %s", pgx.Identifier{c.DatabaseName()}.Sanitize(), sanitizedRoleName)
l.Debug("revoking database grants", zap.String("query", revokeDbQuery))
if _, err := c.db.Exec(ctx, revokeDbQuery); err != nil {
- l.Warn("error revoking database grants", zap.Error(err))
+ l.Error("error revoking database grants", zap.Error(err))
+ return err
}… all types at once.
btipling
left a comment
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
I didn't test the failure case where we intentionally fail, but I did test that account creation and delete worked:
❯ ./dist/darwin_arm64/baton-postgresql --dsn "postgresql://localhost:5432/btipling?sslmode=disable" --create-account-login "testing1234_connector_role"
❯ ./dist/darwin_arm64/baton-postgresql --dsn "postgresql://localhost:5432/btipling?sslmode=disable"
❯ baton principals | grep testing1234
role:16394 | testing1234_connector_role | Role
❯ ./dist/darwin_arm64/baton-postgresql --dsn "postgresql://localhost:5432/btipling?sslmode=disable" --grant-entitlement="entitlement:database:16384:connect" --grant-principal="role:16394" --grant-principal-type="role"
❯ psql -d postgres -c "SELECT rolname FROM pg_roles WHERE rolname LIKE 'testing1234%';"
rolname
----------------------------
testing1234_connector_role
(1 row)
❯ ./dist/darwin_arm64/baton-postgresql --dsn "postgresql://localhost:5432/btipling?sslmode=disable"
❯ baton grants | grep 16394
grant:entitlement:schema:db16384:2200:usage:role:16394 | Schema | btipling - public | USAGE | testing1234_connector_role
grant:entitlement:database:16384:temporary:role:16394 | Database | btipling | TEMPORARY | testing1234_connector_role
grant:entitlement:database:16384:connect:role:16394 | Database | btipling | CONNECT | testing1234_connector_role
./dist/darwin_arm64/baton-postgresql --dsn "postgresql://localhost:5432/btipling?sslmode=disable" --delete-resource="role:16394" --delete-resource-type="role"
./dist/darwin_arm64/baton-postgresql --dsn "postgresql://localhost:5432/btipling?sslmode=disable"
❯ baton grants | grep 16394
❯ baton principals | grep testing1234
❯ psql -d postgres -c "SELECT rolname FROM pg_roles WHERE rolname LIKE 'testing1234%';"
rolname
---------
(0 rows)
ggreer
left a comment
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
A few comments but no blockers to merging. Also adding a delete role CI test would be nice.
pkg/postgres/roles.go
Outdated
| l.Debug("removing role from parent role", zap.String("query", revokeQuery)) | ||
| if _, err := c.db.Exec(ctx, revokeQuery); err != nil { | ||
| l.Error("error removing role from parent role", zap.String("parent_role", parentRole), zap.Error(err)) | ||
| return err |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Nitpick: I think this error would get bubbled up to the user. Do we want to use fmt.Errorf here to make it clearer what the failure is?
(The same goes for other places where we just return err.)
| revokeDbQuery := fmt.Sprintf("REVOKE ALL ON DATABASE %s FROM %s", pgx.Identifier{c.DatabaseName()}.Sanitize(), sanitizedRoleName) | ||
| l.Debug("revoking database grants", zap.String("query", revokeDbQuery)) | ||
| if _, err := c.db.Exec(ctx, revokeDbQuery); err != nil { | ||
| l.Warn("error revoking database grants", zap.Error(err)) |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
We don't want to return these errors? Would it be better to join them and return any errors in this function?
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
I think the play is join them, and return them if the final role deletion fails. Will work on that.
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Actionable comments posted: 1
♻️ Duplicate comments (1)
pkg/postgres/roles.go (1)
174-183: Remove redundant sequence and view checks.Lines 180 and 183 duplicate the check on Line 174, which already covers all
pg_classobjects (tables, sequences, views, etc.) via therelownerfilter. The separate UNION ALL clauses for sequences (relkind = 'S') and views (relkind = 'v') are unnecessary.Based on learnings
Apply this diff to remove the redundant checks:
SELECT EXISTS( SELECT 1 FROM ( -- Check for owned schemas SELECT 1 FROM pg_namespace WHERE nspowner = (SELECT oid FROM pg_roles WHERE rolname = $1) UNION ALL -- Check for owned tables SELECT 1 FROM pg_class WHERE relowner = (SELECT oid FROM pg_roles WHERE rolname = $1) UNION ALL -- Check for owned functions SELECT 1 FROM pg_proc WHERE proowner = (SELECT oid FROM pg_roles WHERE rolname = $1) UNION ALL - -- Check for owned sequences - SELECT 1 FROM pg_class WHERE relowner = (SELECT oid FROM pg_roles WHERE rolname = $1) AND relkind = 'S' - UNION ALL - -- Check for owned views - SELECT 1 FROM pg_class WHERE relowner = (SELECT oid FROM pg_roles WHERE rolname = $1) AND relkind = 'v' - UNION ALL -- Check for owned types SELECT 1 FROM pg_type WHERE typowner = (SELECT oid FROM pg_roles WHERE rolname = $1) UNION ALL -- Check for owned databases SELECT 1 FROM pg_database WHERE datdba = (SELECT oid FROM pg_roles WHERE rolname = $1) ) owned_objects )`
🧹 Nitpick comments (1)
pkg/postgres/roles.go (1)
395-397: Clarify the error handling intent.The
errors.Ischeck on Line 395 prevents early return whengrantsRevokeErroris wrapped witherrRevokeGrantsFromRole. SinceRevokeAllGrantsFromRolealways wraps errors with this sentinel (Line 311), this condition will never be true. If the intent is to always continue despite grant revocation failures and report them at the end, consider simplifying or adding a comment to clarify this behavior.Consider simplifying to:
l.Debug("revoking all grants from role", zap.String("role", roleName)) grantsRevokeError := c.RevokeAllGrantsFromRole(ctx, roleName) if grantsRevokeError != nil { l.Error("error revoking grants from role", zap.Error(grantsRevokeError)) - if !errors.Is(grantsRevokeError, errRevokeGrantsFromRole) { - return fmt.Errorf("error revoking existing grants from role: %w", grantsRevokeError) - } + // Continue to attempt role deletion; errors will be joined at the end if DROP ROLE fails }Or add a comment explaining the intent if you prefer to keep the check.
📜 Review details
Configuration used: CodeRabbit UI
Review profile: CHILL
Plan: Pro
⛔ Files ignored due to path filters (1)
.github/workflows/ci.yamlis excluded by none and included by none
📒 Files selected for processing (1)
pkg/postgres/roles.go(2 hunks)
🧰 Additional context used
🧬 Code graph analysis (1)
pkg/postgres/roles.go (1)
pkg/postgres/client.go (2)
New(121-148)Client(98-102)
⏰ Context from checks skipped due to timeout of 90000ms. You can increase the timeout in your CodeRabbit configuration to a maximum of 15 minutes (900000ms). (1)
- GitHub Check: go-test (1.23.x, ubuntu-latest)
🔇 Additional comments (5)
pkg/postgres/roles.go (5)
17-18: LGTM!The sentinel errors are well-defined and will help identify the source of errors when wrapped with errors.Join.
238-314: Error collection approach looks good.The method now collects all revocation errors using
errors.Joinand returns them wrapped with a sentinel error. This is an improvement over silencing errors, as it attempts to revoke as many grants as possible while still reporting all failures.Based on learnings
317-371: LGTM!The error handling pattern is consistent with
RevokeAllGrantsFromRole, collecting all failures while attempting to remove the role from all parent roles.
409-420: Error aggregation strategy is sound.The approach of attempting all cleanup steps and then joining all errors (Line 415) provides comprehensive failure reporting. This ensures that partial failures are visible while still attempting maximum cleanup.
423-425: LGTM!Clean delegation to
SafeDeleteRolemaintains the public API while adding the safety checks.
In postgres, you are unable to drop a role(account) if that role owns any objects. A role being granted privileges count as objects owned by the role. This means if you try and drop a role with any grants, we'll fail.
Postgres offers the
DROP OWNED BY <role>command to drop any objects owned by the role. It also offersREASSIGNED OWNED BY <role> to <new_role>. We don't have the required context to doREASSIGN, andDROPcomes with a risk that it is all objects, including things like tables/views/functions etc. Postgres will block the command from running if you try and drop an object that something (not owned by the role) depends on. Additionally, the user must be removed from any other role assignments before it can be dropped.This felt like too much risk, so instead I've added a few functions that search for any grants to the role, as well as any role memberships and removes these first, and then removes the role. It also checks to see if the user owns any "database objects" like tables. If it does, we will fail with a specific error saying that these objects should be reassigned first.
Summary by CodeRabbit
New Features
Bug Fixes